Webpack 运行机制与核心工作原理|原理篇
30 秒速记
- 核心判断:构建工具从入口解析依赖图,经转换和优化生成可在目标运行时加载的模块与资源
- 原理主线:围绕 「工作过程简介」、「工作原理剖析」、「写在最后」 建立输入、状态变化与输出之间的因果关系
- 文章范围:介绍了Webpack的核心工作原理和运行机制,包括打包流程、依赖关系解析、Loader与Plugin的作用,以及如何通过源码查阅深入理解Webpack的各个关键环节,帮助前端开发者全面掌握Webpack的工作方式和扩展能力。
- 边界与代价:静态分析受模块语法、动态依赖和副作用声明限制;拆包过细也会增加请求和运行时成本
- 工程落地:优化前先测构建阶段、产物组成和缓存命中,再针对解析、转换、压缩或运行时逐层处理
Webpack 从入口递归解析依赖,把各类资源转换成模块,再合并并输出浏览器可运行的产物。 CLI 会整合命令行参数和配置并创建 Compiler,一次构建的数据则集中在 Compilation 中。进入 make 阶段后,它从入口建立依赖树,调用对应 Loader 处理模块,并借助语法树继续发现依赖。Plugin 通过生命周期钩子扩展构建流程,而不是取代这条核心链路。
这篇文章不要按 API 清单来背。先用上面的 Mind Map 建立全局结构,再通过交互 DEMO 观察正常路径和边界路径如何改变状态;阅读正文时重点核对每一步的输入、负责执行的参与者、产生的中间状态以及最终可观察结果。遇到版本敏感结论,要把“历史实现”“当前行为”和“工程兼容策略”分开说明;遇到性能或架构取舍,则用实际指标、失败现象和验证手段支撑判断。
版本校准: 旧文中的 webpack 4、JSONP 更新清单或 react-hot-loader 代码用于解释历史链路,不应直接复制到新项目。当前 webpack-dev-server 4+ 默认启用 HMR,严格 ESM 可使用 import.meta.webpackHot;生产环境不得携带 HMR runtime。以 webpack HMR 官方指南 和项目锁定版本为准。
# 工作过程简介
其实 Webpack 官网首屏的英雄区就已经很清楚地描述了它的工作原理,如下图所示:

那这里我们先来快速理解一下 Webpack 打包的核心工作过程。我们以一个普通的前端项目为例,项目中一般都会散落着各种各样的代码及资源文件,如下图所示:

比如 JS、CSS、图片、字体等,这些文件在 Webpack 的思想中都属于当前项目中的一个模块。Webpack 可以通过打包,将它们最终聚集到一起。Webpack 在整个打包的过程中:
- 通过 Loader 处理特殊类型资源的加载,例如加载样式、图片;
- 通过 Plugin 实现各种自动化的构建任务,例如自动压缩、自动发布。
具体来看打包的过程,Webpack 启动后,会根据我们的配置,找到项目中的某个指定文件(一般这个文件都会是一个 JS 文件)作为入口。然后顺着入口文件中的代码,根据代码中出现的 import(ES Modules)或者是 require(CommonJS)之类的语句,解析推断出来这个文件所依赖的资源模块,然后再分别去解析每个资源模块的依赖,周而复始,最后形成整个项目中所有用到的文件之间的依赖关系树,下面这个动画生动的演示了这个过程:
有了这个依赖关系树过后, Webpack 会遍历(递归)这个依赖树,找到每个节点对应的资源文件,然后根据配置选项中的 Loader 配置,交给对应的 Loader 去加载这个模块,最后将加载的结果放入 bundle.js(打包结果)中,从而实现整个项目的打包,具体操作可以参考下面的动画:
对于依赖模块中无法通过 JavaScript 代码表示的资源模块,例如图片或字体文件,一般的 Loader 会将它们单独作为资源文件拷贝到输出目录中,然后将这个资源文件所对应的访问路径作为这个模块的导出成员暴露给外部。
整个打包过程中,Loader 机制起了很重要的作用,因为如果没有 Loader 的话,Webpack 就无法实现各种各样类型的资源文件加载,那 Webpack 也就只能算是一个用来合并 JS 模块代码的工具了。
至于自定义插件机制,它并不会影响 Webpack 的核心工作过程,只是 Webpack 为了提供一个强大的扩展能力,它为整个工作过程的每个环节都预制了一个钩子,也就是说我们可以通过插件往 Webpack 工作过程的任意环节植入一些自定义的任务,从而扩展 Webpack 打包功能以外的能力。
至此,你就已经了解到了 Webpack 的核心工作过程。
# 工作原理剖析
对于一般的应用开发过程来说,了解以上这些内容基本上就足以应对了,但如果你想了解 Webpack 整个工作过程的细节,那么你就需要更深入地了解刚刚说到的每一个环节,它们落实到代码层面到底做了些什么,或者说是如何实现的。
在这种情况下,我们就必须有针对性的去“查阅”Webpack 的源代码了。注意这里我们强调的是“查阅”,而不是“阅读”。
至于为什么要扣这个字眼,我们先卖个关子,今天的最后再来解释。
既然是“查阅”,我们就必须要有个线索,否则挨个去看每个源代码,就是无意义的阅读。这里我们的思路就是顺着前面刚刚介绍的 Webpack 打包的核心工作过程,提炼出关键环节,然后再有针对性地去找每个环节的实现方式就可以了。
这里我们先提炼出 Webpack 核心工作过程中的关键环节,明确“查阅”源码的思路:
- Webpack CLI 启动打包流程;
- 载入 Webpack 核心模块,创建 Compiler 对象;
- 使用 Compiler 对象开始编译整个项目;
- 从入口文件开始,解析模块依赖,形成依赖关系树;
- 递归依赖树,将每个模块交给对应的 Loader 处理;
- 合并 Loader 处理完的结果,将打包结果输出到 dist 目录。
接下来我们将对上述关键环节中的部分内容进行详细介绍。
# 一、Webpack CLI
从 Webpack 4 开始 Webpack 的 CLI 部分就被单独抽到了 webpack-cli 模块中,目的是为了增强 Webpack 本身的灵活性。所以这一部分的内容我们需要找到 webpack-cli 所对应的源码。
我们这里分析的是 v3.3.11 版本的 webpack-cli,你可以参考该版本的源码固定链接。
P.S. 目前 webpack-cli 官方仓库默认分支下的代码不是 3.x 版本的。
Webpack CLI 的作用就是将 CLI 参数和 Webpack 配置文件中的配置整合,得到一个完整的配置对象。
这部分操作在 webpack-cli 的入口文件 bin/cli.js 中,这个文件中内容比较多,我们这里只截取部分核心代码,你可以对照截图中的行号找到源代码中对应的位置。
首先,Webpack CLI 会通过 yargs 模块解析 CLI 参数,所谓 CLI 参数指的就是我们在运行 webpack 命令时通过命令行传入的参数,例如 --mode=production,具体位置如下:

紧接着后面,调用了 bin/utils/convert-argv.js 模块,将得到的命令行参数转换为 Webpack 的配置选项对象,具体操作如下:

在 convert-argv.js 工作过程中,首先为传递过来的命令行参数设置了默认值,然后判断了命令行参数中是否指定了一个具体的配置文件路径,如果指定了就加载指定配置文件,反之则需要根据默认配置文件加载规则找到配置文件,具体代码如下:

找到配置文件过后,将配置文件中的配置和 CLI 参数中的配置合并,如果出现重复的情况,会优先使用 CLI 参数,最终得到一个完整的配置选项。
有了配置选项过后,开始载入 Webpack 核心模块,传入配置选项,创建 Compiler 对象,这个 Compiler 对象就是整个 Webpack 工作过程中最核心的对象了,负责完成整个项目的构建工作。

# 二、创建 Compiler 对象
随着 Webpack CLI 载入 Webpack 核心模块,整个执行过程就到了 Webpack 模块中,所以这一部分的代码需要回到 Webpack 模块中,我这里分析的是 v4.43.0 版本的 Webpack,可参考这个版本的源代码的固定链接。
同样,这里我们需要找到这个模块的入口文件,也就是 lib/webpack.js 文件。这个文件导出的是一个用于创建 Compiler 的函数,具体如下:

在这个函数中,首先校验了外部传递过来的 options 参数是否符合要求,紧接着判断了 options 的类型。
根据这个函数中的代码,我们发现 options 不仅仅可以是一个对象,还可以是一个数组。如果我们传入的是一个数组,那么 Webpack 内部创建的就是一个 MultiCompiler,也就是说 Webpack 应该支持同时开启多路打包,配置数组中的每一个成员就是一个独立的配置选项。而如果我们传入的是普通的对象,就会按照我们最熟悉的方式创建一个 Compiler 对象,进行单线打包。

我们顺着主线接着往下看,如下图所示:在创建了 Compiler 对象过后,Webpack 就开始注册我们配置中的每一个插件了,因为再往后 Webpack 工作过程的生命周期就要开始了,所以必须先注册,这样才能确保插件中的每一个钩子都能被命中。

# 三、开始构建
完成 Compiler 对象的创建过后,紧接着这里的代码开始判断配置选项中是否启用了监视模式,具体操作如下:

- 如果是监视模式就调用 Compiler 对象的 watch 方法,以监视模式启动构建,但这不是我们主要关心的主线。
- 如果不是监视模式就调用 Compiler 对象的 run 方法,开始构建整个应用。
这个 run 方法定义在 Compiler 类型中,具体文件在 webpack 模块下的 lib/Compiler.js 中,代码位置如下:

这个方法内部就是先触发了 beforeRun 和 run 两个钩子,然后最关键的是调用了当前对象的 compile 方法,真正开始编译整个项目,具体代码位置如下:

compile 方法内部主要就是创建了一个 Compilation 对象,这个对象我们在插件机制中有提到,Compilation 字面意思是“合集”,实际上,你就可以理解为一次构建过程中的上下文对象,里面包含了这次构建中全部的资源和信息。

创建完 Compilation 对象过后,紧接着触发了一个叫作 make 的钩子,进入整个构建过程最核心的 make 阶段。
# 四、make 阶段
make 阶段主体的目标就是:根据 entry 配置找到入口模块,开始依次递归出所有依赖,形成依赖关系树,然后将递归到的每个模块交给不同的 Loader 处理。

由于这个阶段的调用过程并不像之前一样,直接调用某个对象的某个方法,而是采用事件触发机制,让外部监听这个 make 事件的地方开始执行,所以从这里往后的代码可能找起来会费点劲儿。
这里我简单提示一下:想要知道这个事件触发后,哪些地方会开始执行,前提是得知道哪里注册了这个叫作 make 的事件。
Webpack 的插件系统是基于官方自己的 Tapable 库实现的,我们想要知道在哪里注册了某个事件,必须要知道如何注册的事件。Tapable 的注册方式具体如下:

所以,我们只需要通过开发工具搜索源代码中的 make.tap,就应该能够找到事件注册的位置,具体操作如下:

这里搜索到了六个插件中都注册了 make 事件,这些插件实际上是前面创建 Compiler 对象的时候创建的,刚刚因为没有影响,所以我们就忽略了:

因为我们默认使用的就是单一入口打包的方式,所以这里最终会执行其中的 SingleEntryPlugin。

这个插件中调用了 Compilation 对象的 addEntry 方法,开始解析我们源代码中的入口文件,以此开始“顺藤摸瓜”式的寻找。
因为时间的关系,我在这里就不再带你继续一步一步的往后探索每一个阶段了,你可以顺着我们刚刚的思路,私下继续去尝试。
